昨天用 Mediator 把廚房、收銀、外送平台之間的協調邏輯收進一個中介者
今天聊聊柴咖啡最後一個 Pattern:Iterator 迭代器模式
原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software
Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation.
用中文理解可以是
提供一種方法順序存取一個聚合(集合)物件中的各個元素,而又不暴露該物件的內部表示法
核心角色
IEnumerator<T> 的 MoveNext()/Current)IEnumerable<T> 的 GetEnumerator())UML 會是長什麼樣
我們來聊聊: 為什麼會有 Iterator Design Pattern?
在 Iterator 出現以前,我們寫程式要巡訪一包資料,往往被底層資料結構死死綁架:
for (int i = 0; i < collection.Length; i++) { ... }
var node = collection.First;
while (node != null) { ... node = node.Next; }
所帶來致命的問題,就是外面負責「使用資料的人」,必須對內部「怎麼儲存資料」的細節一清二楚
如果底層結構更改,外面所有寫的 for 迴圈就會出問題
Iterator 的誕生,解決了以下問題
我們來舉個生活化的例子,我開車的時候很喜歡聽廣播,也很喜歡切來切去
我不需要知道收音機內部的結構機制,晶片陣列邏輯,只要按下「下一台」,收音機就依序切到下一個頻道
我們來定義角色概念
駕駛 ME(Client):手握方向盤,只要按方向盤上的「下一台(Next Channel)」按鍵。
收音機旋鈕 / 切台器(Iterator):負責記住目前停在第幾台,每次按鍵就切到下一台
收音機內部儲存(Aggregate):底層到底怎麼存頻率的,駕駛(ME)完全不用在乎
// 1. 迭代器介面:負責轉台與輸出當前播放內容
public interface IRadioTuner
{
bool NextStation(); // 轉動旋鈕:切到下一台並播放 (成功切台回傳 true)
string CurrentPlaying { get; } // 目前喇叭正在播放的電台名稱
}
// 2. 聚合介面:收音機能提供切台旋鈕
public interface ICarRadio
{
IRadioTuner CreateTuner();
}
// 3. 具體迭代器:收音機旋轉鈕,負責管理游標(指針)狀態並直接輸出
public class RadioTuner : IRadioTuner
{
private readonly List<string> _stations;
private int _currentIndex = -1; // 游標狀態:初始為 -1,代表尚未轉台
public RadioTuner(List<string> stations)
{
_stations = stations;
}
// 取得目前正在播放的電台
public string CurrentPlaying => _currentIndex >= 0 && _currentIndex < _stations.Count
? _stations[_currentIndex]
: "靜音 (無播放)";
// 轉動旋鈕:指針前進一格,直接完成切台
public bool NextStation()
{
if (_currentIndex + 1 < _stations.Count)
{
_currentIndex++;
return true; // 切台成功,CurrentPlaying 即時更新
}
Console.WriteLine(">> [嗶] 轉到底了,沒有下一個頻道!");
return false;
}
}
// 4. 具體收音機主機(資料本體)
public class CarRadio : ICarRadio
{
// 資料儲存:內部用 List 儲存,外界完全不需知道底層結構
private readonly List<string> _stations = new();
public void AddStation(string stationName) => _stations.Add(stationName);
// 產出旋鈕迭代器
public IRadioTuner CreateTuner() => new RadioTuner(_stations);
}
// 5. 駕駛開車情境
class Program
{
static void Main()
{
// 建立收音機與預設電台
var radio = new CarRadio();
radio.AddStation("FM 91.7 - 流行音樂台");
radio.AddStation("FM 99.7 - 古典音樂台");
radio.AddStation("FM 103.3 - 中廣流行網");
// 駕駛握住選台旋鈕 (Iterator)
IRadioTuner tuner = radio.CreateTuner();
Console.WriteLine("阿柴開車上路...\n");
// 轉一下,直接換台並播放
RotateKnob(tuner);
Console.WriteLine("\n...開了 10 分鐘,聽膩了順手再轉一下...");
RotateKnob(tuner);
Console.WriteLine("\n...這首也不合胃口,再轉一下...");
RotateKnob(tuner);
Console.WriteLine("\n...想再轉下一首...");
RotateKnob(tuner); // 到底了,跳出提示
}
// 旋鈕轉動動作:轉一下直接發聲
static void RotateKnob(IRadioTuner tuner)
{
if (tuner.NextStation())
{
Console.WriteLine($"[轉動旋鈕] 喇叭直接播放:{tuner.CurrentPlaying}");
}
}
}
換作 C# 譬喻,平常我們很常用到的是 foreach
foreach (var item in list)
{
Console.WriteLine(item);
}
C# 編譯器在編譯時,實際上會把它展開成標準的 Iterator 模式操作:
using (IEnumerator<int> enumerator = list.GetEnumerator()) // 1. 向容器索取 Iterator
{
while (enumerator.MoveNext()) // 2. 相當於 HasNext() + 移動游標
{
var item = enumerator.Current; // 3. 取得當前元素
Console.WriteLine(item);
}
}
不知道上述舉例有沒有讓你能更認識 Iterator
柴咖啡展店到好幾間分店,各分店回報「今天賣出的品項」給總部的方式都不一樣
開最久的那幾間店 POS 系統老舊,資料本來就是用陣列存的
後來新開的店換了新系統,改用 List<T> 存
小黑最近一口氣丟了三個需求過來:
三個需求要拿走訪結果去做的事完全不一樣,但共同點是都得把每一間分店的銷售紀錄從頭到尾看過一遍
如果總部的統計程式碼要分別認得「這間店是陣列、要用 for 迴圈」「那間店是 List、要用 foreach」
以後只要哪間店換系統、底層資料結構一變,統計程式碼就得跟著回頭改
阿柴心想,這正是 Iterator 要解決的問題:每間店的資料要怎麼存,是那間店自己的事,總部只要能用同一種方式走訪就好
跟剛剛收音機的例子一樣,自己刻一組 Iterator 角色:一個負責走訪、一個負責交出走訪器
public class SoldItem
{
public string Name { get; }
public int Price { get; }
public SoldItem(string name, int price)
{
Name = name;
Price = price;
}
}
// 1. Iterator(迭代器介面) / 走訪職責
public interface ISalesIterator
{
bool HasNext();
SoldItem Next();
}
// 2. Aggregate(聚合介面)/ 儲存職責
public interface ISalesRecord
{
ISalesIterator CreateIterator();
}
// 3. ConcreteIterator:走訪陣列版的分店資料
public class ArraySalesIterator : ISalesIterator
{
private readonly SoldItem[] _items;
private int _index = 0;
public ArraySalesIterator(SoldItem[] items) => _items = items;
public bool HasNext() => _index < _items.Length;
public SoldItem Next() => _items[_index++];
}
// 3. ConcreteIterator:走訪 List 版的分店資料
public class ListSalesIterator : ISalesIterator
{
private readonly List<SoldItem> _items;
private int _index = 0;
public ListSalesIterator(List<SoldItem> items) => _items = items;
public bool HasNext() => _index < _items.Count;
public SoldItem Next() => _items[_index++];
}
// 4. ConcreteAggregate:舊分店,資料本來就是用陣列存
public class LegacyStoreSales : ISalesRecord
{
private readonly SoldItem[] _items;
public LegacyStoreSales(SoldItem[] items) => _items = items;
public ISalesIterator CreateIterator() => new ArraySalesIterator(_items);
}
// 4. ConcreteAggregate:新分店,資料改用 List 存
public class NewStoreSales : ISalesRecord
{
private readonly List<SoldItem> _items = new();
public void Add(SoldItem item) => _items.Add(item);
public ISalesIterator CreateIterator() => new ListSalesIterator(_items);
}
不管底層是陣列還是 List,兩間店都實作了同一個 ISalesRecord,呼叫端只認識 CreateIterator() 拿到的 ISalesIterator,不用管背後是怎麼存的
三個需求呼叫端可以直接這樣寫
List<ISalesRecord> allStores = new() { legacyStore, newStore };
var allItems = new List<SoldItem>();
foreach (var store in allStores)
{
var iterator = store.CreateIterator();
while (iterator.HasNext())
{
allItems.Add(iterator.Next()); // 攤平成一份清單,餵給發票系統
}
}
var cheapItems = allItems.Where(item => item.Price < 100).ToList(); // 行銷要的促銷名單
int totalCount = allItems.Count; // 小黑要的今天賣出總數
LegacyStoreSales、NewStoreSales 不知道呼叫端到底要拿這份走訪結果去算總數、篩選,還是丟給發票系統
它們只負責回答「怎麼走訪」這一件事,以後就算又多開一間分店、用了第三種資料結構,只要一樣實作
ISalesRecord/ISalesIterator,上面這段呼叫端程式碼完全不用改
不管是收音機的 IRadioTuner/RadioTuner
還是柴咖啡分店的 ISalesIterator/ArraySalesIterator/ListSalesIterator
做的都是同一件事:自己刻一組 Iterator 角色,配合對應的 Aggregate 一起用
C# 其實已經把 Iterator 這個角色定義成語言內建的兩個介面:
IEnumerator<T>(迭代器)、IEnumerable<T>(聚合物件)
還提供 yield return 讓實作變得很簡短
真的會自己實作的時機,通常只剩下「我有一個自訂的資料結構,想讓它也能被 foreach、被 LINQ 直接使用」
Iterator 要解決的問題:
隱藏底層資料結構(資訊隱藏):使用者只想「一個一個拿出資料」,不需要知道內部是用陣列、二元樹、鏈結串列還是圖結構儲存
職責單一化(SRP):若將「資料儲存」與「遍歷演算法」混在同一個集合類別中,集合負擔過重。Iterator 將「遍歷與游標管理」抽離出來,讓集合專注於資料儲存與增刪
提供一致的走訪介面:對呼叫端而言,無論面對何種集合,存取迴圈的程式碼風格完全統一
優點:
呼叫端不用管底層資料結構長什麼樣,統一用同一種方式走訪(foreach、或語言提供的迭代語法)
走訪邏輯集中在迭代器自己,不會散落在每一個想走訪這個聚合物件的呼叫端,各自重寫一次遞迴或索引邏輯
同一個聚合物件可以同時存在多個獨立的走訪過程,彼此互不干擾,各自記著自己走到哪
缺點:
主流語言大多把這個 Pattern 內建進語言基礎設施(foreach/for...of/生成器),日常業務程式碼很少需要自己手刻一個,真正動手實作的情境比其他 Pattern 少見
走訪過程中如果底層集合的內容被修改,容易跟走訪狀態不同步,需要額外設計因應
明天,30 天的最後一篇,來做整個系列的總結
Microsoft Docs - IEnumerable<T> Interface